--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit b80d22ad0b43b35929c30da955b640806be228cb
Parents : d2eb616
Author : Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-04-25T16:29:46-05:00
feat(android): update audio handling and navigation for Android, including native audio attachment support and message routing
Changes
4 files changed, 271 insertions(+), 38 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index d4e88cd3..f276975e 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -1737,6 +1737,14 @@ export default {
if (!normalizedUrl) {
return;
}
+ if (/^meshchatx:\/\/app\/messages\/?/i.test(normalizedUrl)) {
+ this.$router.push({ name: "messages" });
+ return;
+ }
+ if (/^meshchatx:\/\/app\/call\/?/i.test(normalizedUrl)) {
+ this.$router.push({ name: "call", query: { tab: "phone" } });
+ return;
+ }
if (/^(meshchatx|meshchat):\/\/map\b/i.test(normalizedUrl)) {
WebSocketConnection.send(
JSON.stringify({
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index 66b63c64..a5f578ad 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -2226,6 +2226,8 @@ export default {
selectedAudioInputId: null,
selectedAudioOutputId: null,
remoteAudioEl: null,
+ useAndroidNativeTelephone: false,
+ androidNativeTelephoneListener: null,
};
},
computed: {
@@ -2385,6 +2387,13 @@ export default {
formatDuration(seconds) {
return Utils.formatMinutesSeconds(seconds);
},
+ isMeshChatXAndroid() {
+ return (
+ window.MeshChatXAndroid &&
+ typeof window.MeshChatXAndroid.getPlatform === "function" &&
+ window.MeshChatXAndroid.getPlatform() === "android"
+ );
+ },
getMediaDevicesApi() {
const mediaDevices = navigator?.mediaDevices;
if (!mediaDevices || typeof mediaDevices.getUserMedia !== "function") {
@@ -2460,11 +2469,11 @@ export default {
this.stopWebAudio();
},
async ensureWebAudio(webAudioStatus) {
- if (!this.config?.telephone_web_audio_enabled) {
+ if (!webAudioStatus?.enabled) {
this.stopWebAudio();
return;
}
- if (this.activeCall && webAudioStatus?.enabled) {
+ if (this.activeCall && webAudioStatus.enabled) {
this.audioFrameMs = webAudioStatus.frame_ms || 60;
await this.startWebAudio();
} else {
@@ -2501,6 +2510,45 @@ export default {
if (!this.activeCall) {
return;
}
+ if (this.isMeshChatXAndroid()) {
+ this.stopWebAudio();
+ if (
+ !window.MeshChatXAndroid ||
+ typeof window.MeshChatXAndroid.startTelephoneNativeAudio !== "function"
+ ) {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ new Error("Native audio bridge not linked"),
+ "start-android-missing"
+ );
+ return;
+ }
+ const telMic = window.MeshChatXAndroid.isTelephoneNativeAudioAvailable;
+ const micOk =
+ typeof telMic === "function"
+ ? telMic()
+ : window.MeshChatXAndroid.isNativePcmAudioAvailable?.() === true;
+ if (!micOk) {
+ await this.disableWebAudioBridgeWithError(
+ "call.microphone_permission_denied",
+ new Error("RECORD_AUDIO not granted"),
+ "start-android-perm"
+ );
+ return;
+ }
+ const ret = window.MeshChatXAndroid.startTelephoneNativeAudio();
+ if (ret !== "ok") {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ new Error(String(ret || "native start")),
+ "start-android"
+ );
+ return;
+ }
+ this._bindAndroidNativeTelephone();
+ this.useAndroidNativeTelephone = true;
+ return;
+ }
if (this.audioWs && this.audioWs.readyState === WebSocket.OPEN) {
try {
this.audioWs.send(JSON.stringify({ type: "attach" }));
@@ -2563,35 +2611,84 @@ export default {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const url = `${wsProtocol}//${window.location.host}/ws/telephone/audio`;
- if (!this.audioCtx.audioWorklet || typeof this.audioCtx.audioWorklet.addModule !== "function") {
- await this.disableWebAudioBridgeWithError(
- "call.web_audio_not_available",
- new Error("AudioWorklet is unavailable in this browser"),
- "start-preflight-audio-worklet"
- );
- return;
- }
- await this.audioCtx.audioWorklet.addModule(telephonePcmCaptureWorkletUrl);
- const processor = new AudioWorkletNode(this.audioCtx, "telephone-pcm-capture", {
- numberOfInputs: 1,
- numberOfOutputs: 1,
- channelCount: 1,
- });
- processor.port.onmessage = (event) => {
+ const sendMicPcmToWs = (arrayBuffer) => {
if (!this.audioWs || this.audioWs.readyState !== WebSocket.OPEN) {
return;
}
- const data = event.data;
- if (data && data.byteLength > 0) {
- this.audioWs.send(data);
+ if (arrayBuffer && arrayBuffer.byteLength > 0) {
+ this.audioWs.send(arrayBuffer);
+ }
+ };
+
+ const floatChannelToInt16PcmBuffer = (ch0) => {
+ const pcm = new Int16Array(ch0.length);
+ for (let i = 0; i < ch0.length; i += 1) {
+ const s = ch0[i];
+ pcm[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
}
+ return pcm.buffer;
};
- source.connect(processor);
- this.audioWorkletNode = processor;
+
+ let micTapNode = null;
+
+ if (
+ globalThis.isSecureContext !== false &&
+ this.audioCtx.audioWorklet &&
+ typeof this.audioCtx.audioWorklet.addModule === "function"
+ ) {
+ try {
+ await this.audioCtx.audioWorklet.addModule(telephonePcmCaptureWorkletUrl);
+ const processor = new AudioWorkletNode(this.audioCtx, "telephone-pcm-capture", {
+ numberOfInputs: 1,
+ numberOfOutputs: 1,
+ channelCount: 1,
+ });
+ processor.port.onmessage = (event) => {
+ sendMicPcmToWs(event.data);
+ };
+ source.connect(processor);
+ this.audioWorkletNode = processor;
+ micTapNode = processor;
+ } catch (workletErr) {
+ this.logWebAudioFailure("telephone-worklet-add", workletErr);
+ }
+ }
+
+ if (!micTapNode) {
+ if (typeof this.audioCtx.createScriptProcessor !== "function") {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ new Error("AudioWorklet and ScriptProcessor capture are unavailable"),
+ "start-preflight-audio-capture"
+ );
+ return;
+ }
+ try {
+ const scriptNode = this.audioCtx.createScriptProcessor(4096, 1, 1);
+ scriptNode.onaudioprocess = (e) => {
+ const ch0 = e.inputBuffer.getChannelData(0);
+ if (!ch0 || ch0.length === 0) {
+ return;
+ }
+ sendMicPcmToWs(floatChannelToInt16PcmBuffer(ch0));
+ };
+ source.connect(scriptNode);
+ this.audioProcessor = scriptNode;
+ micTapNode = scriptNode;
+ } catch (scriptErr) {
+ await this.disableWebAudioBridgeWithError(
+ "call.web_audio_not_available",
+ scriptErr,
+ "start-preflight-script-processor"
+ );
+ return;
+ }
+ }
+
const silentGain = this.audioCtx.createGain();
silentGain.gain.value = 0;
this.audioSilentGain = silentGain;
- processor.connect(silentGain);
+ micTapNode.connect(silentGain);
silentGain.connect(this.audioCtx.destination);
const ws = new WebSocket(url);
@@ -2648,6 +2745,15 @@ export default {
},
async requestAudioPermission() {
try {
+ if (this.isMeshChatXAndroid()) {
+ const tel = window.MeshChatXAndroid?.isTelephoneNativeAudioAvailable;
+ if (typeof tel === "function" && tel()) {
+ return true;
+ }
+ if (window.MeshChatXAndroid?.isNativePcmAudioAvailable?.()) {
+ return true;
+ }
+ }
const mediaDevices = this.getMediaDevicesApi();
if (!mediaDevices) {
throw new Error("navigator.mediaDevices is unavailable");
@@ -2762,7 +2868,34 @@ export default {
bufferSource.start();
}
},
+ _bindAndroidNativeTelephone() {
+ this._unbindAndroidNativeTelephone();
+ this.androidNativeTelephoneListener = (ev) => {
+ const d = ev && ev.detail;
+ if (d && d.kind === "error" && d.detail) {
+ this.logWebAudioFailure("android-native", new Error(String(d.sub || d.detail || "error")));
+ }
+ };
+ window.addEventListener("meshchatx-native-telephone-audio", this.androidNativeTelephoneListener);
+ },
+ _unbindAndroidNativeTelephone() {
+ if (this.androidNativeTelephoneListener) {
+ window.removeEventListener("meshchatx-native-telephone-audio", this.androidNativeTelephoneListener);
+ this.androidNativeTelephoneListener = null;
+ }
+ },
stopWebAudio() {
+ if (this.useAndroidNativeTelephone) {
+ this._unbindAndroidNativeTelephone();
+ this.useAndroidNativeTelephone = false;
+ try {
+ if (window.MeshChatXAndroid?.stopTelephoneNativeAudio) {
+ window.MeshChatXAndroid.stopTelephoneNativeAudio();
+ }
+ } catch (e) {
+ this.logWebAudioFailure("android-native-stop", e);
+ }
+ }
const ws = this.audioWs;
this.audioWs = null;
if (ws) {
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index f58253e9..727b0b52 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -1498,7 +1498,7 @@
<script>
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
-import { numOrNull } from "../../js/interfaceDiscoveryUtils";
+import { numOrNull, parseRNodeFrequencyHz } from "../../js/interfaceDiscoveryUtils";
import ExpandingSection from "./ExpandingSection.vue";
import FormLabel from "../forms/FormLabel.vue";
import Toggle from "../forms/Toggle.vue";
@@ -1705,7 +1705,7 @@ export default {
return !this.parseBool(v);
},
formattedFrequency() {
- const totalHz = this.calculateFrequencyInHz();
+ const totalHz = Math.round(this.calculateFrequencyInHz());
if (totalHz >= 1e9) {
return `${(totalHz / 1e9).toFixed(3)} GHz`;
} else if (totalHz >= 1e6) {
@@ -1766,6 +1766,7 @@ export default {
return Boolean(value);
},
numOrNull,
+ parseRNodeFrequencyHz,
async loadReticulumDiscoveryConfig() {
try {
const response = await window.api.get(`/api/v1/reticulum/discovery`);
@@ -2013,10 +2014,12 @@ export default {
// Radio params
if (config.frequency) {
- const freq = Number(config.frequency);
- this.RNodeGHzValue = Math.floor(freq / 1e9);
- this.RNodeMHzValue = Math.floor((freq % 1e9) / 1e6);
- this.RNodekHzValue = Math.floor((freq % 1e6) / 1e3);
+ const hz = this.parseRNodeFrequencyHz(config.frequency);
+ if (hz != null && hz > 0) {
+ this.RNodeGHzValue = Math.floor(hz / 1e9);
+ this.RNodeMHzValue = Math.floor((hz % 1e9) / 1e6);
+ this.RNodekHzValue = Math.floor((hz % 1e6) / 1e3);
+ }
}
if (config.bandwidth) this.newInterfaceBandwidth = Number(config.bandwidth);
if (config.txpower) this.newInterfaceTxpower = Number(config.txpower);
@@ -2115,7 +2118,7 @@ export default {
listen_ip: config.listen_ip || null,
listen_port: this.numOrNull(config.listen_port),
port: config.port || null,
- frequency: this.numOrNull(config.frequency),
+ frequency: this.parseRNodeFrequencyHz(config.frequency) ?? this.numOrNull(config.frequency),
bandwidth: this.numOrNull(config.bandwidth),
txpower: this.numOrNull(config.txpower),
spreadingfactor: this.numOrNull(config.spreadingfactor),
@@ -2268,7 +2271,7 @@ export default {
this.isSaving = true;
try {
const discoveryEnabled = this.discovery.discoverable === true;
- const freqHz = this.calculateFrequencyInHz();
+ const freqHz = Math.round(this.calculateFrequencyInHz());
const i2pPeers =
this.newInterfaceType === "I2PInterface"
@@ -2376,7 +2379,7 @@ export default {
}
},
calculateFrequencyInHz() {
- return this.RNodeGHzValue * 1e9 + this.RNodeMHzValue * 1e6 + this.RNodekHzValue * 1e3;
+ return Math.round(this.RNodeGHzValue * 1e9 + this.RNodeMHzValue * 1e6 + this.RNodekHzValue * 1e3);
},
updateRNodeCalculations() {
this.calculateRNodeParameters(
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index ca2f8bcc..203eba54 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -1612,6 +1612,7 @@ export default {
audioAttachmentRecordingStartedAt: null,
audioAttachmentRecordingDuration: null,
audioAttachmentRecordingTimer: null,
+ androidNativeOpusAttachment: false,
lxmfMessageAudioAttachmentCache: {},
isDownloadingAudio: {},
expandedMessageInfo: null,
@@ -2125,6 +2126,13 @@ export default {
}
},
methods: {
+ isMeshChatXAndroid() {
+ return (
+ window.MeshChatXAndroid &&
+ typeof window.MeshChatXAndroid.getPlatform === "function" &&
+ window.MeshChatXAndroid.getPlatform() === "android"
+ );
+ },
setupPeerHeaderResizeObserver() {
this.teardownPeerHeaderResizeObserver();
const root = this.$refs.conversationPeerHeader;
@@ -5048,13 +5056,38 @@ export default {
break;
}
case "opus": {
- // start recording microphone
+ if (this.isMeshChatXAndroid() && window.MeshChatXAndroid?.startNativeWavAttachment) {
+ if (
+ typeof window.MeshChatXAndroid.isNativePcmAudioAvailable === "function" &&
+ !window.MeshChatXAndroid.isNativePcmAudioAvailable()
+ ) {
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
+ break;
+ }
+ const res = window.MeshChatXAndroid.startNativeWavAttachment();
+ if (res !== "ok") {
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
+ break;
+ }
+ this.androidNativeOpusAttachment = true;
+ this.audioAttachmentMicrophoneRecorderCodec = "opus";
+ this.audioAttachmentMicrophoneRecorder = { _androidNative: true };
+ this.audioAttachmentRecordingStartedAt = Date.now();
+ this.isRecordingAudioAttachment = true;
+ this.audioAttachmentRecordingDuration = Utils.formatMinutesSeconds(0);
+ this.audioAttachmentRecordingTimer = setInterval(() => {
+ const recordingDurationMillis = Date.now() - this.audioAttachmentRecordingStartedAt;
+ const recordingDurationSeconds = recordingDurationMillis / 1000;
+ this.audioAttachmentRecordingDuration =
+ Utils.formatMinutesSeconds(recordingDurationSeconds);
+ }, 1000);
+ break;
+ }
this.audioAttachmentMicrophoneRecorderCodec = "opus";
this.audioAttachmentMicrophoneRecorder = new MicrophoneRecorder();
this.audioAttachmentRecordingStartedAt = Date.now();
this.isRecordingAudioAttachment = await this.audioAttachmentMicrophoneRecorder.start();
- // update recording time in ui every second
this.audioAttachmentRecordingDuration = Utils.formatMinutesSeconds(0);
this.audioAttachmentRecordingTimer = setInterval(() => {
const recordingDurationMillis = Date.now() - this.audioAttachmentRecordingStartedAt;
@@ -5062,7 +5095,6 @@ export default {
this.audioAttachmentRecordingDuration = Utils.formatMinutesSeconds(recordingDurationSeconds);
}, 1000);
- // alert if failed to start recording
if (!this.isRecordingAudioAttachment) {
DialogUtils.alert(this.buildAudioRecordingFailureMessage());
}
@@ -5079,13 +5111,65 @@ export default {
// clear audio recording timer
clearInterval(this.audioAttachmentRecordingTimer);
- // do nothing if not recording
if (!this.isRecordingAudioAttachment) {
return;
}
- // stop recording microphone and get audio
this.isRecordingAudioAttachment = false;
+ if (this.androidNativeOpusAttachment) {
+ this.androidNativeOpusAttachment = false;
+ const p = new Promise((resolve) => {
+ const done = () => {
+ try {
+ if (window.__meshchatXNative) {
+ window.__meshchatXNative = undefined;
+ }
+ } catch {
+ // ignore
+ }
+ resolve();
+ };
+ window.__meshchatXNative = {
+ onWav: (payload) => {
+ if (!payload || !payload.ok) {
+ const err = payload && payload.error ? String(payload.error) : "unknown";
+ if (err !== "empty") {
+ DialogUtils.alert(`${this.$t("messages.failed")}${err ? ` (${err})` : ""}`);
+ }
+ done();
+ return;
+ }
+ try {
+ const binary = atob(payload.data);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i += 1) {
+ bytes[i] = binary.charCodeAt(i);
+ }
+ const audio = new Blob([bytes], { type: "audio/wav" });
+ this.newMessageAudio = {
+ audio_mode: 0x10,
+ audio_blob: audio,
+ audio_preview_url: URL.createObjectURL(audio),
+ };
+ } catch {
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
+ }
+ done();
+ },
+ };
+ try {
+ window.MeshChatXAndroid.stopNativeWavAttachment();
+ } catch {
+ DialogUtils.alert(this.buildAudioRecordingFailureMessage());
+ done();
+ }
+ });
+ await p;
+ this.audioAttachmentMicrophoneRecorder = null;
+ this.audioAttachmentMicrophoneRecorderCodec = null;
+ return;
+ }
+
const audio = await this.audioAttachmentMicrophoneRecorder.stop();
// handle audio based on codec
@@ -5164,7 +5248,12 @@ export default {
let probe = null;
try {
probe = new AudioContextCtor();
- if (!probe.audioWorklet || typeof probe.audioWorklet.addModule !== "function") {
+ const canWorklet =
+ globalThis.isSecureContext !== false &&
+ probe.audioWorklet &&
+ typeof probe.audioWorklet.addModule === "function";
+ const canScriptProcessor = typeof probe.createScriptProcessor === "function";
+ if (!canWorklet && !canScriptProcessor) {
return `${this.$t("messages.failed_start_recording")}. ${this.$t("messages.failed_start_recording_help_audio_worklet")}`;
}
} catch {
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────